Skip to content

feat: serve content-hashed chunks outside BUILD_ID path#3779

Open
bartlomieju wants to merge 4 commits intomainfrom
ib/content-addressed-assets
Open

feat: serve content-hashed chunks outside BUILD_ID path#3779
bartlomieju wants to merge 4 commits intomainfrom
ib/content-addressed-assets

Conversation

@bartlomieju
Copy link
Copy Markdown
Member

Summary

  • Shared chunks and assets (WASM, etc.) produced by esbuild already have content hashes in their filenames (chunk-XXXX.js), but were served under /_fresh/js/{BUILD_ID}/ — meaning every deploy invalidated all cached chunks, even unchanged ones
  • Use esbuild's chunkNames/assetNames options to output shared chunks to ../c/, which the builder maps to /_fresh/js/c/ — outside the BUILD_ID directory
  • Entry points (fresh-runtime, island modules) remain at /_fresh/js/{BUILD_ID}/ since they don't have content hashes in their filenames
  • The static files middleware now treats /_fresh/js/c/ as immutable, so browsers cache these files across deploys

Before: /_fresh/js/{BUILD_ID}/chunk-abc123.js — URL changes every deploy even if content didn't change
After: /_fresh/js/c/chunk-abc123.js — URL only changes when content changes

This is especially impactful for large assets like WASM files that rarely change but are expensive to re-download.

Test plan

  • Existing static file tests pass
  • New test verifies immutable caching for /_fresh/js/c/ paths (both JS chunks and WASM assets)
  • Manual test with a project that has shared chunks/WASM to verify browser caching across deploys
  • Verify source maps resolve correctly for relocated chunks

…ching

esbuild already content-hashes shared chunks and assets (chunk-XXXX.js,
file-XXXX.wasm), but Fresh served them under /_fresh/js/{BUILD_ID}/.
This meant every deploy invalidated all cached chunks — even unchanged
ones like large WASM files.

Move shared chunks/assets to /_fresh/js/c/ (outside the BUILD_ID
directory) so their URLs only change when their content changes. Entry
points (fresh-runtime, islands) remain under /_fresh/js/{BUILD_ID}/.
Adds a `contentAddressedStatic` glob pattern option to BuildOptions.
Matching static files use their content hash (instead of BUILD_ID) as
the asset() cache-bust key, so their URLs survive deploys unchanged.

- asset() checks a content hash registry for matching files
- ProdBuildCache populates the registry from snapshot on startup
- Middleware accepts content hash as a valid cache-bust key
- Matching files get immutable cache headers in production
Port the contentAddressedStatic option to FreshViteConfig so it works
with the modern App/Vite path (not just the legacy Builder API).
Static files matching the glob patterns are marked immutable in the
snapshot, enabling content-hash caching that survives deploys.
Copy link
Copy Markdown
Member Author

@bartlomieju bartlomieju left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall: Clean PR, well-structured. Two distinct but related features: (1) esbuild shared chunks always go to /_fresh/js/c/ (automatic), (2) user static files opt in via contentAddressedStatic globs. Good test coverage and docs.

Issues:

  1. Glob matching inconsistency — In dev_build_cache.ts:363, isContentAddressed(name) is called where name is a URL-style pathname (e.g., /large.wasm). In server_snapshot.ts:284, isContentAddressed(relative) uses a filesystem-relative path (e.g., large.wasm, no leading slash). The same user-provided glob pattern like **/*.wasm may match one but not the other depending on how globToRegExp handles the leading /. Worth adding a test that exercises both paths with the same pattern, or normalizing the input before matching.

  2. Source maps for relocated chunks — The test plan has "Verify source maps resolve correctly for relocated chunks" unchecked. Since chunks move from /_fresh/js/{BUILD_ID}/ to /_fresh/js/c/, the //# sourceMappingURL= comment esbuild emits is relative. If the .map file also lands under ../c/ it should be fine, but this is worth verifying before merge — broken source maps in production are painful to debug after the fact.

  3. file.hash !== cacheKey broadens valid cache keys (static_files.ts:68) — Before this change, only BUILD_ID was accepted as a cache key. Now any file's content hash is also accepted. This is correct for content-addressed files, but it also applies to all static files — if a request comes in with a matching hash for a non-content-addressed file, it'll get immutable caching too. This is probably fine (correct hash = correct content), but it's a subtle behavioral change worth calling out in the PR description.

Nits:

  1. The || undefined pattern in dev_build_cache.ts:366 and server_snapshot.ts:322 (immutable: isContentAddressed(name) || undefined) is a bit subtle — a short comment like // omit false to keep default behavior would help.

  2. The "../c/" string appears in both esbuild.ts and builder.ts — consider extracting it as a shared constant to keep them in sync.

CertainLach added a commit to CertainLach/freshpack that referenced this pull request Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants